Skip to content

[BETA] - #181

Open
brinkflew wants to merge 12 commits into
mainfrom
beta
Open

[BETA]#181
brinkflew wants to merge 12 commits into
mainfrom
beta

Conversation

@brinkflew

Copy link
Copy Markdown
Contributor

Next beta version

brinkflew added 12 commits July 29, 2026 13:32
Allow change states and parameters of a local database without
running it:

- change or remove linked worktree
- change or remove linked virtual environment
- change or remove linked repository
- (un)whitelist
Reworks the `odev database` command so that it edits databases through the model
rather than writing to the data store directly, and adds the same capability to
`odev code <database> <repository>`, which is the invocation issue #101 reports.

`odev code` accepting both a database and a repository has worked since
odev-plugin-editor-base@abd6288 removed the error the issue was filed for, but the
repository applied to that single invocation and was never saved, so the next
`odev code <database>` had lost it again. It is now persisted, which is also what
makes the database usable with every other command relying on that link.

Both go through the new `LocalDatabase.link_repository`, which normalizes its
argument through `GitConnector`: repository names, HTTPS and SSH URLs and paths to
local clones are all accepted and stored as `organization/repository`. Storing the
raw argument, as the command did, corrupted the link for anything but a bare name:
the stored value is split on its first slash when read back, so a URL yielded an
organization of `https:`.

Other fixes to the command:

- Values were written with `UPDATE ... WHERE name = ...`, which matches no row for
  a database that odev has never run, so setting a parameter on one silently did
  nothing while reporting success. Setting now goes through the model, whose write
  is an upsert.
- Changing the repository left the branch of the previous one attached to the new
  one. The `repository` setter now clears the cached branch.
- `--set-repo` and `--remove-repo` (and their venv and worktree counterparts) could
  be passed together, in which case the removal silently won. They are now rejected
  as mutually exclusive. `Command._exclusive_arguments` cannot express this: it
  requires exactly one argument of the group to be present, making the group
  mandatory as well as exclusive.
- Called with no argument but the database, the command did nothing and said
  nothing; it now prints the current parameters, as asked in review.
- `GitConnector` errors surfaced raw instead of going through `self.error`.

`StoreDatabases.set_value` is kept, since clearing a value is the one thing that
cannot go through the model — the properties fall back to reading the data store
when their cached value is empty, and would write the cleared value straight back.
It now binds its value as a query parameter and checks the column against the table
definition, instead of interpolating both into the query: `repr()` quotes strings
containing a quote with double quotes, which PostgreSQL reads as an identifier.
`PostgresDatabase.query` grew the `params` argument it needs to forward for that,
which `PostgresConnector.query` already accepted.

Two supporting fixes:

- `GitCommand.worktrees` read `self.args.version` guarded only by the presence of
  `args`. A command removing that argument has no `version` attribute at all, so
  any use of `grouped_worktrees` raised `AttributeError`.
- `args.Flag` dropped the default it was given when passed an explicit action, so
  `args.FlagOptional` could not be given one.

Closes #101
`args.FlagOptional` is the argument the `database` command relies on to tell a
value being turned off apart from a value being left alone, and the tutorial only
documented `args.Flag`.

Claude-Session: https://claude.ai/code/session_01K8csZBrrBYp8oqH5paxTAm
…#135)

* [FIX] python: install a setuptools that can build Odoo's dependencies

Installing Odoo's requirements failed on `gevent` with
`BackendUnavailable: Cannot import 'setuptools.build_meta'`: odev pinned
setuptools to 58-59 for python 3.8 to 3.11, and those versions cannot serve as a
PEP 517 backend for current pip, which is what `--no-build-isolation` builds
against. The floor is raised to 69.

It is also capped below 82, which removed `pkg_resources`. Odoo 15.0 and 16.0
import it unconditionally in `odoo/modules/module.py` and odev runs both on python
3.10, where an uncapped requirement resolves to setuptools 83 and `odoo-bin` stops
importing altogether. Users who upgraded setuptools by hand to work around this
issue will see it downgraded.

Neither bound had any effect before, because requirements were parsed with a
regular expression capturing a single operator and version, and expecting the
environment marker to follow the version immediately. Any requirement combining
two bounds lost the second one *and* its marker: the existing
`setuptools>=58.0.0, <59.0.0; python_version >= '3.8' and python_version < '3.12'`
had always been read as an unconditional `setuptools>=58.0.0`. Requirements are now
parsed with `packaging.requirements.Requirement`, which also removes the `eval()`
of the comparison and of the marker.

Markers are evaluated against the python of the virtual environment the
requirements are being installed into, not against the interpreter odev runs under,
so a requirement conditioned on the python version resolves for the Odoo
installation it is meant for.

Odoo builds several of its dependencies from source, and those builds fail with
errors that do not name the system library they are missing. When creating a
virtual environment, odev now lists the system packages Odoo declares in
`setup/debinstall.sh` that are not installed, warns about them and offers to run
the script. Detection uses the script's `--list` mode, which needs no privileges,
and is skipped outside of Debian-based systems and for the versions of Odoo that
predate the script. The prompt defaults to declining, since prompts return their
default when running with `--force`, in headless mode and under tests, and the
script is run through an explicit `sudo` because it silently downgrades to a dry
run and exits successfully when not run as root.

Closes #93

* [IMP] system: report missing dependencies on any operating system

The system dependency check introduced with the setuptools fix only worked
on Debian and Ubuntu: it listed the packages Odoo declares in `debian/control`
and offered to run `setup/debinstall.sh` with `sudo`. On macOS, Fedora or Arch
it reported nothing at all, and the user only found out something was missing
when the build of gevent failed in the compiler.

Odev does not control the machine it runs on, so it now describes what is
missing in plain words and, when it recognizes the package manager, prints the
command installing it. It no longer runs that command itself.

Where Odoo lists its own packages they are still used, and anywhere else the
check falls back to probing what an executable on the `PATH` proves present, so
that nothing is reported as missing on a distribution that names it
differently. The development headers of python are checked on every system:
`debian/control` asks for `python3-dev`, which is not necessarily the version
of python the Odoo installation is built against.

`PythonEnv.install_system_packages` used to raise `Neither dnf or apt package
managers found on the system` when it ran anywhere else, naming neither the
packages to install nor a way to move forward. It now reports them and returns
whether it installed anything, so that `create` gives up once instead of asking
the same question again on a system where the answer cannot change.

Claude-Session: https://claude.ai/code/session_01K8csZBrrBYp8oqH5paxTAm
`FetchCommand.run` clears the blank line that `Command.table` appends after
the last worktree summary. `PullCommand` overrides `run_hook` with plain log
lines and never emitted that trailing blank, so the cleanup erased the summary
of the last repository instead.

Give the pull hook the same output shape as the fetch one: a section title
introducing each worktree and a blank line closing it. The worktree name moves
from every log message to that title, and the contract is now documented on
`FetchCommand.run_hook` so future overrides keep it.

Claude-Session: https://claude.ai/code/session_012tWyDuUYenE92nsBtG19Du
)

`odev quickstart` neutralized databases without ever running the neutralization
scripts shipped by their custom modules, and ran `odoo-bin neutralize` with an
`--addons-path` that did not contain the custom repository at all. Three separate
defects had to line up for that:

- `quickstart` linked the repository to the new database *after* restoring it,
  but neutralization runs from within `restore`. At that point the database had
  no repository, so no addons path could be derived from it. The repository is
  now linked before the restore, and again after it, since `restore` drops and
  recreates the database and clears its entry in the data store.

- `additional_addons_paths` returned the repository root as-is. A repository
  keeping its modules in subdirectories is not itself a valid addons path, so it
  was filtered out downstream and never reached `--addons-path`. The lookup for
  the directories actually holding modules only existed in `OdoobinCommand`,
  which `odev neutralize` does not inherit from; it now lives on `OdoobinProcess`
  as `expand_addons_paths` and is shared by both.

- The neutralization scripts of custom modules were looked up by intersecting the
  installed module names with the *directory names of the addons paths*. An addons
  path is a directory containing modules, never a module itself, so the
  intersection was always empty and the spinner reported "0 installed modules".
  The lookup now builds the module map from the subdirectories of each addons path.

Also fixes an infinite loop in `LocalDatabase.neutralize`: the retry counter was
reset by a walrus assignment evaluated on every iteration of the loop it was
guarding, so `odev neutralize` on a database odev cannot start never returned.

Closes #98
* [IMP] common: halve the time odev takes to start and exit

Every invocation paid ~1.6s of framework overhead before the requested
command even began, and kept the user waiting ~0.35s after it had already
printed its result. For a tool run dozens of times a day that overhead is
the dominant part of how slow odev feels, and none of it was the command
itself: `odev version` measured 1.59s median, of which 0.02s was the
command.

Five causes, all of them paid unconditionally:

- Telemetry blocked the main thread on an HTTPS round-trip in
  `run_command`'s `finally`. Records are now spooled locally when a command
  ends and submitted in the background by a later run, so no invocation
  ever waits on the endpoint. This also fixes `Telemetry.update()`, which
  never ran: its `len(_command_stack) != 1` guard was evaluated after the
  stack had been popped, so exit codes and execution times were silently
  dropped. The employee check, which ran a vault lookup, an SSH-agent
  decryption and a call to git on every command, is now cached in the
  configuration.

- Scanning the sources for interactive debuggers spawned two `grep`
  processes at import time. It now runs on demand and its result is cached
  on disk, keyed on a walk of the command trees that costs a fraction of
  the scan itself.

- The GitHub API client, paramiko, InquirerPy and black were imported by
  the framework although only a handful of commands need them; the
  connector and mixin packages re-exported everything they contained.
  Those imports moved to their point of use and the packages resolve their
  names lazily. Networkx was only used to sort the plugin dependency graph
  and is replaced by a topological sort, reporting the same cycles.

- Discovering commands executed all 46 command modules only to read their
  names, so every run paid for every command, plugins included. Names,
  aliases and help texts are now cached in an index and a command module is
  only imported once that command runs. Plugin patching is replayed from
  the recorded registration order, leaving the output of `odev help`
  unchanged.

- `CaptureOutput` attached its handlers to the loggers that existed when it
  was entered, which no longer covers command modules imported while a
  command runs. It now captures on the root logger, like odev does in
  production.

`odev version` goes from 1.59s to 0.73s median, measured by interleaving
runs against a clean checkout, and imports 1022 modules instead of 1503.
`tools/benchmark_startup.py` reports the breakdown, and the new tests guard
the structural causes rather than timings, which are too noisy to assert on.

Claude-Session: https://claude.ai/code/session_012tWyDuUYenE92nsBtG19Du

* [FIX] tests: warm the command index before asserting it stays lazy

The first run of a new version has no index yet and legitimately imports
every command to build one, so the startup tests only passed on a machine
where a previous run had already warmed it up. On a clean checkout they
measured the cold path and failed, and test_03 only passed because test_02
happened to run first and leave an index behind.

Claude-Session: https://claude.ai/code/session_012tWyDuUYenE92nsBtG19Du

* [FIX] tooling: mark the startup benchmark as executable

The script carries a shebang and is meant to be run directly, which ruff
enforces through EXE001. The mode bit went unnoticed locally because the
working tree lives on a filesystem that does not report it faithfully.

Claude-Session: https://claude.ai/code/session_012tWyDuUYenE92nsBtG19Du
## Description

`odev plugin` could only enable, disable or show a plugin whose name you already knew. This PR adds discovery to the command, and makes `--show` the detail view for every plugin odev can know about.

- **`odev plugin --search [terms]`** — searches GitHub for published plugins, keeping only repositories exposing a valid manifest at their root. The `odev` keyword alone is unusable (it collides with unrelated repositories, mostly Turkish *ödev*), so repositories are matched on both `odev` and `plugin` in name, description or topics; on the current index that returns 13 real plugins out of 14 hits. Archived repositories and [the template repository](https://github.com/odoo-odev/odev-plugin-template), which cannot be installed, are left out. `--limit` caps how many repositories are inspected (20 by default), bounding the cost to `1 + N` API requests.
- **`odev plugin --list`** — every plugin available locally with its state: `enabled`, `disabled` (downloaded but not linked), `missing` (link gone) or `shadowed`. That last state was previously invisible: two enabled plugins forked from one another map to the same module name and only one is ever loaded — which is the case today for `odoo-odev/odev-plugin-editor-vscode` and its `avanserv` fork.
- **`odev plugin --show`** — now built on the same discovery, so it reports states consistently with `--list`. A plugin that is not on the machine is looked up on GitHub, so uninstalled and never-downloaded plugins are described too; plugins present locally are read from disk and never trigger a request. Failing to reach GitHub falls back to the local information instead of raising. Without an argument, `--show` details every local plugin rather than the enabled ones only.

Searching and listing never install anything — installing remains `odev plugin --enable <organization>/<repository>`.

Supporting changes:

- **`parse_plugin_manifest()`** (`odev/common/odev.py`) — reads a manifest with `ast`, taking only the module docstring and top-level literal assignments. Manifests are otherwise loaded through `exec_module`, which is fine for a repository the user explicitly installed but unacceptable for arbitrary search results. A top-level `__version__` string is what identifies a repository as an odev plugin. `plugin_module_name()` is extracted at the same time, replacing the same expression repeated in four places.
- **`GithubConnector`** (`odev/common/connectors/git.py`) — `GitConnector` requires an `organization/repository` at construction, so there was nowhere for a search to live. The API connection concern is extracted into a new base class (a pure move of the token, connection and authentication members; `GitConnector` inherits from it and keeps its public API), which gains `search_repositories()`, `get_repository()` and `get_repository_file()`. All three return `None` rather than raising when a result is missing or unreadable.

Also fixes `--show <organization>/<repository>`, which **always** reported a plugin as disabled: the name was stripped of its organization before being compared to the enabled plugins, which are stored fully qualified. A repository name on its own is now accepted too, as long as it is not ambiguous.

## Testing

- Full test suite: **165 passed** (143 → 157 → 165; 18 new tests covering the manifest parser, the connector and the three command modes), py3.14, local PostgreSQL.
- `pre-commit run --all-files` clean; `basedpyright` reports no new error against the baseline.
- Exercised against the real GitHub API from a live checkout: `--search`, `--search ai`, `--search` with no result, `--list`, and `--show` on an enabled plugin (no request), the template repository (template warning, no install hint), an archived plugin (archived warning), a non-existent repository, the shadowed `avanserv` fork, a bare unknown name, and with no argument.
- A manifest containing `os.system(...)` and `raise SystemExit(1)` at module level is parsed with no side effect (covered by a unit test).

## Compliance

- [x] I have read the [contribution guide](../docs/CONTRIBUTING.md)
- [x] I made sure the documentation is up-to-date both in doctrings and the `docs` directory
- [x] I have added or modified unit tests where necessary
- [x] I have added new libraries to the `requirements.txt` file, if any
- [x] I have incremented the version number according the [versioning guide](../../docs/contributing/versioning.md)
- [x] The PR contains **my changes only** and **no other external commit**

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01H9M1zJCzxpg35ijsDcPEPA
## Description

`odev version` warned "A newer version is available, consider running 'odev update'" on an up-to-date checkout, and `odev update` could not make the warning go away:

```
 odev version
[i] Odev version 4.30.0 (main)
[!] A newer version is available, consider running 'odev update'
 odev update
[i] Current version: 4.30.0
[i] Odev is up to date
 odev version
[i] Odev version 4.30.0 (main)
[!] A newer version is available, consider running 'odev update'
```

The check never looked at the remote. It compared `config.update.version` — the marker recording which version the upgrade scripts last ran for — against `_version.py`, with `!=`, so it fired in both directions, and the command *displayed* that marker instead of the version actually running. Any drift (typically switching back from the `beta` release channel) made the warning stick forever, since `upgrade()` only rewrites the marker when the code version is higher. That drift also silently suppresses the upgrade script of the version it is stuck on.

The version command now reports the running version and warns based on the remote tracking branch, through a new `Odev.update_available()` that reuses `__git_branch_behind()` and the ref already fetched by the periodic check — no network call and nothing added to the startup path.

Along the same lines in the self-update flow, which behaved as if an update existed when it did not:

- Restore the early return dropped in fea554e: the result of `__git_branch_behind()` was discarded, so every check went on to prompt (`mode = ask`) and pull (`mode = always`) even when up to date. The check now runs again after fetching, so a commit arriving in that very fetch is still pulled in the same run.
- Record `update.date` whenever a check ran instead of only when something was pulled, as its name implies and as returning early now requires — otherwise every single command would fetch again.
- Reset a recorded version that is ahead of the running one, from `upgrade()` and when switching release channel, so the marker cannot drift.
- Bail out of the behind check on a detached HEAD instead of accessing the active branch, which raises.

Verified with the full suite (10 new tests covering the behind/ahead/up-to-date matrix, detached HEAD, the early return, the date stamping and the marker reset), and end to end against a throwaway local repository pair: up to date fetches only and pulls nothing, remote ahead pulls in the same run.

## Linked Issues

None.

## Compliance

- [x] I have read the [contribution guide](../docs/CONTRIBUTING.md)
- [x] I made sure the documentation is up-to-date both in doctrings and the `docs` directory
- [x] I have added or modified unit tests where necessary
- [x] I have added new libraries to the `requirements.txt` file, if any (none added)
- [x] I have incremented the version number according the [versioning guide](../../docs/contributing/versioning.md)
- [x] The PR contains **my changes only** and **no other external commit**

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01ADsnaLfNDoobrG9bf8NfmV
…st suite (#178)

## Why

This started as filling coverage gaps, and each step surfaced the next:

1. `.coveragerc` gates at 60% and the suite sat at **64%**, with the gap widest on pure logic the rest of the framework leans on. Writing those tests surfaced **six defects in the helpers** — without fixing them the tests would have pinned broken behaviour.
2. Verifying the fixes meant running the suite repeatedly, which is when it became clear that **two suites cannot run at once**: identical invocations produced anywhere from 0 to 68 failures, and the machine had accumulated 16 orphaned `/tmp/odev-test-*` directories.
3. Isolating the runs revealed that three concurrent suites exhaust PostgreSQL's connection slots, which turned out to be a **connection leak in odev itself**, not in the suite.

The four sections below are independent and the commits are ordered to be reviewed in sequence.

## 1. Helper defects — `42569c4`

| Location | Defect |
|---|---|
| `connectors/postgres.py` `columns_exist` | Returned `[]` when **none** of the requested columns existed — indistinguishable from all being present. `CREATE TABLE IF NOT EXISTS` leaves an existing table alone, so the missing-columns pass is the only thing that can migrate a table created from an older definition; it silently added nothing. |
| `postgres.py` `PostgresDatabase.tables` | A class attribute, so every instance shared one registry and tables from different databases collided on their name alone. |
| `string.py` `quote` | Chose its delimiter with `max()` over both quote offsets, picking the **last** rather than the first, mis-quoting any string mixing them. |
| `version.py` `OdooVersion.__bool__` | Always `True` — `module` is padded to `MIN_VERSION_LENGTH` and is never an empty tuple. |
| `string.py` `min_indent` | Raised `ValueError` on a text without any non-blank line, reachable from `odev help` through `dedent`. |
| `float_to_hours`, `strip_styles` | Broken, but called nowhere in odev nor in the plugins. **Left alone**, documented in the tests with the correction spelled out. |

`columns_exist` has exactly one caller, and it runs after `CREATE TABLE IF NOT EXISTS`, so the fix cannot make it issue `ALTER TABLE` against a missing table.

## 2. Coverage for the untested helpers — `be95197`

- **`test_string.py`** (new) — `string.py` had no test module at all, despite backing `odev help`, `odev history` and the local database listing query. Sizes and their round-trip, indentation, joining, the `dirty_only` × `force_single` quoting matrix, Rich markup helpers, and the `help` column alignment contract.
- **`test_git_worktree.py`** (new) — `connectors/git.py` was the least-covered large module (34%), and its `GitWorktree` parser turns `git worktree list --porcelain` into the objects the whole `fetch` / `pull` / `worktree` family works with. Porcelain parsing (branch, detached, bare, locked, prunable with reasons), the `-odev-` local-branch split that `create_worktree` writes and `fetch` / `pull` read back, identity by path, and `pending_changes` including the two swallowed `GitCommandError` messages. No network, no real repository.
- **`test_postgres_table.py`** (new) — `PostgresTable.__add_missing_column`, the datastore's migration path, was entirely unreached; this covers it including the `InvalidTableDefinition` primary-key branch.
- **`test_version.py`** — ordering (`15.0 < 16.0 < saas-16.4 < 17.0 < master`) is what actually picks a revision at runtime and nothing compared two versions.

Corrections to existing tests, in the same commit:

- **`test_bash.py` shelled out to a real `sudo cat >> /etc/shadow`.** The premise that the command fails only holds for an unprivileged user whose shell cannot open the redirection — a machine granting passwordless sudo runs it for real, and as root it appends to the file or hangs on stdin. The subprocess and the effective user are now simulated, which also lets the elevation path be asserted rather than inferred.
- `test_odev.py` left a command line behind in `sys.argv` for whichever test ran next.
- `tests/fixtures/case.py` — `_patches` was a list defined on `OdevTestCase` and mutated through `cls._patches.append`, so every subclass shared it and each class tore down the patches of all the classes before it.

## 3. An isolated, self-cleaning test suite — `87dc0e4`, `86630d3`, `716c8e1`

`Odev.name` was the constant `"odev-test"` and **every** shared resource derived from it, so two suites shared one namespace and actively destroyed each other:

- `test_99_delete_expression` ran `odev delete --expression "^odev-test-[a-z0-9]{8}" --include-whitelisted` against the real PostgreSQL, deleting a concurrent run's databases.
- `PostgresDatabase.drop()` terminates every backend on `datname`, so each class teardown killed a concurrent run's cursors.
- `CREATE TABLE IF NOT EXISTS` is not atomic, and `Config.save()` truncate-writes a fixed path — hence `UniqueViolation` on `pg_type_typname_nsp_index` and `DuplicateOptionError` from a torn config.

A run now claims a sandbox named after itself and holds an exclusive `flock` on it for its whole life. Everything — datastore, test databases, config, temp directories — is named after it or nested under it. Cleanup runs at `pytest_sessionfinish`, which pytest calls from a `finally`, so `Ctrl+C` is covered; `SIGTERM` becomes the same orderly exit; and the next run's sweep collects whatever a `SIGKILL` left, because the kernel releases the lock when the owner dies whatever the cause.

**The suite was also writing outside its sandbox**, which is worth a look on its own:

- `TestSetup` ran the install scripts against their real destinations, so running the suite **repointed the developer's `~/.local/bin/odev` and bash-completion symlinks at whichever checkout it ran from**. `symlink.py` computed the destination halfway through creating it, leaving no way to redirect it; that decision moves to `link_path`.
- Tests cloned into the real `~/odoo/repositories`. The repositories, dumps and upgrade paths now point inside the sandbox, as does `CONFIG_DIR` — which also means the suite no longer picks up whichever plugins the developer happens to have installed, so a local run and CI exercise the same code.

`87dc0e4` is a separate product fix this surfaced: `LocalDatabase.is_odoo` checks that a database exists and then connects to it, and any process can drop it in between — `odev list` inspects every database in turn and would fail outright because one went away.

Interrupt handling is covered by `tests/tests/common/test_interrupts.py`: odev captures `SIGINT` around every query to cancel just that statement, so a `Ctrl+C` was previously swallowed and the run carried on. Letting it through instead abandons the connection mid-statement, so the interrupt is recorded and acted upon at the next test boundary.

## 4. Connection lifetime — `47499cb`, `cd07007`

Both database context managers built a **second, unconnected** connector to close instead of the one they had connected, so `disconnect()` did nothing and the connection stayed open until the garbage collector got to it:

```python
def __enter__(self):
    self.connector = self._connector_class(self.name).__enter__()   # connector A, connected
    return self

def __exit__(self, *args):
    self._connector_class(self.name).__exit__(*args)                # connector B, never connected
```

`ensure_connected` runs every database method inside its own block and those blocks nest — `is_odoo` opens one and then calls `table_exists`, which opens another — so this meant a fresh backend per call.

Closing the right connector is **not enough on its own**: an inner block would close the connection the enclosing one is still using. The blocks are now reentrant and share a single connector, counted in `PostgresConnectorMixin` so both classes get the same behaviour. The datastore holds its connection instead of reopening it per read — every command reads it and it lives as long as the process, which is not true of the databases odev walks through for `list` or `delete`.

A connection pool keyed per database was considered and set aside: `list --all` and `delete --expression` touch **every** database on the server briefly, so a per-database pool would hold one idle backend per Odoo database until the process ends — the very exhaustion this fixes — unless it also grew a global cap and idle eviction.

Measured over a full suite run:

| | before | after |
|---|---|---|
| peak backends held | 42 | **3** |
| mean backends held | 8.7 | **0.8** |
| suite duration | 54.8s | **33.6s** |
| three concurrent suites | died on `max_connections` | **249 passed each**, peak 7 backends |

The speedup was not the goal — it is what a backend fork plus an authentication round-trip per query costs.

## Coverage

| Module | Before | After |
|---|---|---|
| `common/string.py` | 85% | **100%** |
| `common/version.py` | 96% | **100%** |
| `common/postgres.py` | 81% | **93%** |
| `common/connectors/git.py` | 34% | **40%** |
| **Total** | **64%** | **65%** |

## Verification

- `pytest tests` — **249 passed**, from 242 on the first revision
- Two and three concurrent suites — **249 passed each**, repeatedly, leaving zero directories and zero databases behind
- `SIGINT`, `SIGTERM` and `SIGKILL` mid-run — each verified to leave nothing behind, the last one via the next run's sweep
- `odev list --all`, `odev history`, `odev version` — smoke-checked, no connections surviving the process
- `pre-commit run --all-files` — clean
- `basedpyright` — 3 errors, all pre-existing on `beta`; **0 new**

## Notes for reviewers

- `odev/_version.py` is bumped once, to `4.29.10`. `origin/beta` is at `4.29.9`; PRs #175, #176 and #177 each bump from the same base, so whichever merges second needs a one-line rebase.
- `LocalDatabase.connector: PostgresConnector | None = None` was removed as dead — `ConnectorMixin.__init__` overwrites it with the connector *class* at construction, which also meant the `if self.connector is not None` guard in `_restore` never protected anything. It is now the `isinstance` check `drop()` already used.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_012tWyDuUYenE92nsBtG19Du
## Description

`odev run` inside a git clone links it to the database under a name built from directory names —
`f"{path.parent.name}/{path.name}"` — even though it already holds the real path and has just
checked that it is a git repository. For a clone that does not follow the
`<repositories>/<organization>/<repository>` layout that name is wrong, and `GitConnector.path`
expands it back to a directory that does not exist:

```
repositories root:  ~/odoo/dev
clone:              ~/odoo/dev/tutorials
stored as:          dev/tutorials
resolved back to:   ~/odoo/dev/dev/tutorials
```

The wrong name is persisted, so every later command resolving the link looks in the duplicated
directory.

- `OdoobinProcess.additional_repositories` passes the path to `GitConnector`, so the name is read
  from the git remote — the only reliable source of truth — and the directory names become a
  fallback rather than the value.
- `GitConnector` resolves its name in `_name_from_remote` / `_name_from_string` / `_fallback_name`.
  Reading the remote used to raise for a repository that has none and for a URL with fewer than two
  segments, and it ignored remotes not named `origin`. All three now fall back to the name passed to
  the connector instead of raising.
- `OdoobinCommand._guess_addons_paths` no longer pins a repository whose directory is missing. It
  preferred the stored repository over the current directory unconditionally, so a database already
  linked under a wrong name could never be re-detected and silently ran without its custom addons.
  It now warns, names the missing path and falls back to the current directory — which is what lets
  an affected link repair itself on the next run.

### Note for reviewers

Databases stored under a wrong name repair themselves the next time `odev run` is used from the
clone, but `save_database_repository` asks once whether to relink ("already linked to another
repository"). That prompt is the migration path. There is deliberately no upgrade script:
`__validate_upgrade_script` keys off the directory name, so it would need renaming on every rebase,
and the state is self-healing anyway.

`GitConnector.path` keeps expanding `<repositories>/<name>` with no flat-layout fallback. It also
feeds `clone()`, `fix_corrupted()` (which `rmtree`s it), `remove`, `worktrees` and
`requirements_path`, and a name-only match is ambiguous across organizations.

### Tests

`tests/tests/common/test_git_connector.py` grows from name parsing only to real repositories built
with `Repo.init` + `create_remote` in a temporary directory: the name read from the remote overrides
the directory names (the regression guard for this issue), HTTPS and non-`origin` remotes, no remote
at all, an unparseable remote, an absolute path that is not a git repository, and the `path`
argument taking precedence over the conventional location. A second class covers the process end to
end — a flat clone directly under the repositories root now resolves to its own directory. Full test
suite passes.

## Linked Issues

- closes #92

## Compliance

- [x] I have read the [contribution guide](../docs/CONTRIBUTING.md)
- [x] I made sure the documentation is up-to-date both in doctrings and the `docs` directory
- [x] I have added or modified unit tests where necessary
- [x] I have added new libraries to the `requirements.txt` file, if any
- [x] I have incremented the version number according the [versioning guide](../../docs/contributing/versioning.md)
- [x] The PR contains **my changes only** and **no other external commit**

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01K8csZBrrBYp8oqH5paxTAm
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant